Fork me on GitHub

Egg 源码分析之 egg-core(二)

转载自知乎网络,原文链接:https://zhuanlan.zhihu.com/p/47180589

loadService 函数

如何在 Egg 框架中使用 service

loadService 函数的实现是所有load函数中最复杂的一个,我们不着急看源码,先看一下 service 在 Egg 框架中如何使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// egg-core 源码 -> 如何在 egg 框架中使用 service

//方式 1 :app/service/user1.js
//这个是最标准的做法,导出一个 class ,这个 class 继承了 require('egg').Service ,其实也就是我们上文提到的 eggCore 导出的 BaseContextClass
//最终我们在业务逻辑中获取到的是这个class的一个实例,在 load 的时候是将 app.context 当作新建实例的参数
//在 controller 中调用方式:this.ctx.service.user1.find(1)
const Service = require('egg').Service;
class UserService extends Service {
async find(uid) {
//此时我们可以通过 this.ctx,this.app,this.config,this.service 获取到有用的信息,尤其是 this.ctx 非常重要,每个请求对应一个 ctx,我们可以查询到当前请求的所有信息
const user = await this.ctx.db.query('select * from user where uid = ?', uid);
return user;
}
}
module.exports = UserService;

//方式 2 :app/service/user2.js
//这个做法是我模拟了一个 BaseContextClass,当然也就可以实现方法 1 的目的,但是不推荐
class UserService {
constructor(ctx) {
this.ctx = ctx;
this.app = ctx.app;
this.config = ctx.app.config;
this.service = ctx.service;
}
async find(uid) {
const user = await this.ctx.db.query('select * from user where uid = ?', uid);
return user;
}
}
module.exports = UserService;

//方式 3 :app/service/user3.js
// service 中也可以 export 函数,在 load 的时候会主动调用这个函数,把 appInfo 参数传入,最终获取到的是函数返回结果
//在 controller 中调用方式:this.ctx.service.user3.getAppName(1) ,这个时候在 service 中获取不到当前请求的上下文 ctx
module.exports = (appInfo) => {
return {
async getAppName(uid){
return appInfo.name;
}
}
};

//方式 4 :app/service/user4.js
// service 也可以直接 export 普通的原生对象,load 的时候会将该普通对象返回,同样获取不到当前请求的上下文 ctx
//在 controller 中调用方式:this.ctx.service.user4.getAppName(1)
module.exports = {
async getAppName(uid){
return appInfo.name;
}
};

我们上面列举了 service 下的 js 文件的四种写法,都是从每次请求的上下文 this.ctx 获取到 service 对象,然后就可以使用到每个 service 文件导出的对象了,这里主要有两个地方需要注意:

  1. 为什么我们可以从每个请求的 this.ctx 上获取到 service 对象呢:
    看过 Koa 源码的同学知道,this.ctx 其实是从 app.context 继承而来,所以我们只要把 service 绑定到 app.context 上,那么当前请求的上下文 ctx 自然可以拿到 service 对象,EggLoader 也是这样做的
  2. 针对上述四种使用场景,具体导出实例是怎么处理的呢?
  • 如果导出的是一个类,EggLoader 会主动以 ctx 对象去初始化这个实例并导出,所以我们就可以直接在该类中使用 this.ctx 获取当前请求的上下文了
  • 如果导出的是一个函数,那么 EggLoader 会以 app 作为参数运行这个函数并将结果导出
  • 如果是一个普通的对象,直接导出

FileLoader 类的实现分析

在实现 loadService 函数时,有一个基础类就是 FileLoader ,它同时也是 loadMiddleware,loadController 实现的基础,这个类提供一个 load 函数根据目录结构和文件内容进行解析,返回一个 target 对象,我们可以根据文件名以及子文件名以及函数名称获取到 service 里导出的内容,target 结构类似这样:

1
2
3
4
5
6
7
8
9
10
{
"file1": {
"file11": {
"function1": a => a
}
},
"file2": {
"function2": a => a
}
}

下面我们先看一下 FileLoader 这个类的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
// egg-core 源码 -> FileLoader 实现

class FileLoader {
constructor(options) {
/* options 里几个重要参数的含义:
1. directory: 需要加载文件的所有目录
2. target: 最终加载成功后的目标对象
3. initializer:一个初始化函数,对文件导出内容进行初始化,这个在 loadController 实现时会用到
4. inject:如果某个文件的导出对象是一个函数,那么将该值传入函数并执行导出,一般都是 this.app
*/
this.options = Object.assign({}, defaults, options);
}
load() {
//解析 directory 下的文件,下面有 parse 函数的部分实现
const items = this.parse();
const target = this.options.target;
// item1 = { properties: [ 'a', 'b', 'c'], exports1 },item2 = { properties: [ 'a', 'b', 'd'], exports2 }
// => target = {a: {b: {c: exports1, d: exports2}}}
//根据文件路径名称递归生成一个大的对象 target ,我们通过 target.file1.file2 就可以获取到对应的导出内容
for (const item of items) {
item.properties.reduce((target, property, index) => {
let obj;
const properties = item.properties.slice(0, index + 1).join('.');
if (index === item.properties.length - 1) {
obj = item.exports;
if (obj && !is.primitive(obj)) {
//这步骤很重要,确定这个 target 是不是一个 exports ,有可能只是一个路径而已
obj[FULLPATH] = item.fullpath;
obj[EXPORTS] = true;
}
} else {
obj = target[property] || {};
}
target[property] = obj;
return obj;
}, target);
}
return target;
}

//最终生成 [{ properties: [ 'a', 'b', 'c'], exports,fullpath}] 形式, properties 文件路径名称的数组, exports 是导出对象, fullpath 是文件的绝对路径
parse() {
//文件目录转换为数组
let directories = this.options.directory;
if (!Array.isArray(directories)) {
directories = [ directories ];
}
//遍历所有文件路径
const items = [];
for (const directory of directories) {
//每个文件目录下面可能还会有子文件夹,所以 globby.sync 函数是获取所有文件包括子文件下的文件的路径
const filepaths = globby.sync(files, { cwd: directory });
for (const filepath of filepaths) {
const fullpath = path.join(directory, filepath);
if (!fs.statSync(fullpath).isFile()) continue;
//获取文件路径上的以 "/" 分割的所有文件名,foo/bar.js => [ 'foo', 'bar' ],这个函数会对 propertie 同一格式,默认为驼峰
const properties = getProperties(filepath, this.options);
// app/service/foo/bar.js => service.foo.bar
const pathName = directory.split(/[/\\]/).slice(-1) + '.' + properties.join('.');
// getExports 函数获取文件内容,并将结果做一些处理,看下面实现
const exports = getExports(fullpath, this.options, pathName);
//如果导出的是 class ,会设置一些属性,这个属性下文中对于 class 的特殊处理地方会用到
if (is.class(exports)) {
exports.prototype.pathName = pathName;
exports.prototype.fullPath = fullpath;
}
items.push({ fullpath, properties, exports });
}
}
return items;
}
}

//根据指定路径获取导出对象并作预处理
function getExports(fullpath, { initializer, call, inject }, pathName) {
let exports = utils.loadFile(fullpath);
//用 initializer 函数对exports结果做预处理
if (initializer) {
exports = initializer(exports, { path: fullpath, pathName });
}
//如果 exports 是 class,generatorFunction,asyncFunction 则直接返回
if (is.class(exports) || is.generatorFunction(exports) || is.asyncFunction(exports)) {
return exports;
}
//如果导出的是一个普通函数,并且设置了 call=true,默认是 true,会将 inject 传入并调用该函数,上文中提到过好几次,就是在这里实现的
if (call && is.function(exports)) {
exports = exports(inject);
if (exports != null) {
return exports;
}
}
//其它情况直接返回
return exports;
}

ContextLoader 类的实现分析

上文中说到 loadService 函数其实最终把 service 对象挂载在了 app.context 上,所以为此提供了 ContextLoader 这个类,继承了 FileLoader 类,用于将 FileLoader 解析出来的 target 挂载在 app.context 上,下面是其实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// egg-core -> ContextLoader 类的源码实现

class ContextLoader extends FileLoader {
constructor(options) {
const target = options.target = {};
super(options);
// FileLoader 已经讲过 inject 就是 app
const app = this.options.inject;
// property 就是要挂载的属性,比如 "service"
const property = options.property;
//将 service 属性挂载在 app.context 上
Object.defineProperty(app.context, property, {
get() {
//做缓存,由于不同的请求 ctx 不一样,这里是针对同一个请求的内容进行缓存
if (!this[CLASSLOADER]) {
this[CLASSLOADER] = new Map();
}
const classLoader = this[CLASSLOADER];
//获取导出实例,这里就是上文用例中获取 this.ctx.service.file1.fun1 的实现,这里的实例就是 this.ctx.service,实现逻辑请看下面的 getInstance 的实现
let instance = classLoader.get(property);
if (!instance) {
//这里传入的 this 就是为了初始化 require('egg').Service 实例时当作参数传入
// this 会根据调用者的不同而改变,比如是 app.context 的实例调用那么就是 app.context ,如果是 app.context 子类的实例调用,那么就是其子类的实例
//就是因为这个 this ,如果 service 里继承require('egg').Service ,才可以通过 this.ctx 获取到当前请求的上下文
instance = getInstance(target, this);
classLoader.set(property, instance);
}
return instance;
},
});
}
}

// values 是 FileLoader/load 函数生成 target 对象
function getInstance(values, ctx) {
//上文 FileLoader 里实现中我们讲过,target 对象是一个由路径和 exports 组装成的一个大对象,这里 Class 是为了确定其是不是一个 exports ,有可能是一个路径名
const Class = values[EXPORTS] ? values : null;
let instance;
if (Class) {
if (is.class(Class)) {
//这一步很重要,如果是类,就用 ctx 进行初始化获取实例
instance = new Class(ctx);
} else {
//普通对象直接导出,这里要注意的是如果是 exports 函数,在 FileLoader 实现中已经将其执行并转换为了对象
// function 和 class 分别在子类和父类的处理的原因是, function 的处理逻辑 loadMiddleware,loadService,loadController 公用,而 class 的处理逻辑 loadService 使用
instance = Class;
}
} else if (is.primitive(values)) {
//原生类型直接导出
instance = values;
} else {
//如果目前的 target 部分是一个路径,那么会新建一个 ClassLoader 实例,这个 ClassLoader 中又会递归的调用 getInstance
//这里之所以新建一个类,一是为了做缓存,二是为了在每个节点获取到的都是一个类的实例
instance = new ClassLoader({ ctx, properties: values });
}
return instance;
}

loadService 的实现

有了 ContextLoader 类,那实现 loadService 函数就非常容易了,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// egg-core -> loadService 函数实现源码
// loadService 函数调用 loadToContext 函数
loadService(opt) {
opt = Object.assign({
call: true,
caseStyle: 'lower',
fieldClass: 'serviceClasses',
directory: this.getLoadUnits().map(unit => path.join(unit.path, 'app/service')), //所有加载单元目录下的 service
}, opt);
const servicePaths = opt.directory;
this.loadToContext(servicePaths, 'service', opt);
}
// loadToContext 函数直接新建 ContextLoader 实例,调用 load 函数实现加载
loadToContext(directory, property, opt) {
opt = Object.assign({}, {
directory,
property,
inject: this.app,
}, opt);
new ContextLoader(opt).load();
}

loadMiddleware 函数

中间件是 Koa 框架中很重要的一个环节,通过 app.use 引入中间件,使用洋葱圈模型,所以中间件加载的顺序很重要。 - 如果在上文中的 config 中配置的中间件,系统会自动用 app.use 函数使用该中间件 - 所有的中间件我们都可以在 app.middleware 中通过中间件 name 获取到,便于在业务中动态使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// egg-core 源码 -> loadMiddleware 函数实现源码

loadMiddleware(opt) {
const app = this.app;
opt = Object.assign({
call: false, // call=false 表示如果中间件导出是函数,不会主动调用函数做转换
override: true,
caseStyle: 'lower',
directory: this.getLoadUnits().map(unit => join(unit.path, 'app/middleware')) //所有加载单元目录下的 middleware
}, opt);
const middlewarePaths = opt.directory;
//将所有中间件 middlewares 挂载在 app 上,这个函数在 loadController 实现中也用到了,看下文的实现
this.loadToApp(middlewarePaths, 'middlewares', opt);
//将 app.middlewares 中的每个中间件重新绑定在 app.middleware 上,每个中间件的属性不可配置,不可枚举
for (const name in app.middlewares) {
Object.defineProperty(app.middleware, name, {
get() {
return app.middlewares[name];
},
enumerable: false,
configurable: false,
});
}
//只有在 config 中配置了 appMiddleware 和 coreMiddleware 才会直接在 app.use 中使用,其它中间件只是挂载在 app 上,开发人员可以动态使用
const middlewareNames = this.config.coreMiddleware.concat(this.config.appMiddleware);
const middlewaresMap = new Map();
for (const name of middlewareNames) {
//如果 config 中定义 middleware 在 app.middlewares 中找不到或者重复定义,都会报错
if (!app.middlewares[name]) {
throw new TypeError(`Middleware ${name} not found`);
}
if (middlewaresMap.has(name)) {
throw new TypeError(`Middleware ${name} redefined`);
}
middlewaresMap.set(name, true);
const options = this.config[name] || {};
let mw = app.middlewares[name];
//中间件的文件定义必须 exports 一个普通 function ,并且接受两个参数:
// options: 中间件的配置项,框架会将 app.config[${middlewareName}] 传递进来, app: 当前应用 Application 的实例
//执行 exports 的函数,生成最终要的中间件
mw = mw(options, app);
mw._name = name;
//包装中间件,最终转换成 async function(ctx, next) 形式
mw = wrapMiddleware(mw, options);
if (mw) {
app.use(mw);
this.options.logger.info('[egg:loader] Use middleware: %s', name);
} else {
this.options.logger.info('[egg:loader] Disable middleware: %s', name);
}
}
}

//通过 FileLoader 实例加载指定属性的所有文件并导出,然后将该属性挂载在 app 上
loadToApp(directory, property, opt) {
const target = this.app[property] = {};
opt = Object.assign({}, {
directory,
target,
inject: this.app,
}, opt);
new FileLoader(opt).load();
}

loadController 函数

controller 中生成的函数最终还是在 router.js 中当作一个中间件使用,所以我们需要将 controller 中内容转换为中间件形式 async function(ctx, next) ,其中 initializer 这个函数就是用来针对不同的情况将 controller 中的内容转换为中间件的,下面是 loadController 的实现逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// egg-core源码 -> loadController 函数实现源码

loadController(opt) {
opt = Object.assign({
caseStyle: 'lower',
directory: path.join(this.options.baseDir, 'app/controller'),
//这个配置,上文有提到,是为了对导出对象做预处理的函数
initializer: (obj, opt) => {
//如果是普通函数,依然直接调用它生成新的对象
if (is.function(obj) && !is.generatorFunction(obj) && !is.class(obj) && !is.asyncFunction(obj)) {
obj = obj(this.app);
}
if (is.class(obj)) {
obj.prototype.pathName = opt.pathName;
obj.prototype.fullPath = opt.path;
//如果是一个 class,class 中的函数转换成 async function(ctx, next) 中间件形式,并用 ctx 去初始化该 class ,所以在 controller 里我们也可以使用 this.ctx.xxx 形式
return wrapClass(obj);
}
if (is.object(obj)) {
//如果是一个 Object ,会递归的将该 Object 中每个属性对应的函数转换成 async function(ctx, next) 中间件形式形式
return wrapObject(obj, opt.path);
}
if (is.generatorFunction(obj) || is.asyncFunction(obj)) {
return wrapObject({ 'module.exports': obj }, opt.path)['module.exports'];
}
return obj;
},
}, opt);
// loadController 函数同样是通过 loadToApp 函数将其导出对象挂载在 app 下,controller 里的内容在 loadRouter 时会将其载入
const controllerBase = opt.directory;
this.loadToApp(controllerBase, 'controller', opt);
},

loadRouter 函数

loadRouter 函数特别简单,只是 require 加载一下 app/router 目录下的文件而已,而所有的事情都交给了 EggCore 类上的 router 属性去实现

而 router 又是 Router 类的实例,Router 类是基于 koa-router 实现的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// egg-core 源码 -> loadRouter 函数源码实现

loadRouter() {
this.loadFile(this.resolveModule(path.join(this.options.baseDir, 'app/router')));
}

//设置 router 属性的 get 方法
get router() {
//缓存设置
if (this[ROUTER]) {
return this[ROUTER];
}
//新建 Router 实例,其中 Router 类是继承 koa-router 实现的
const router = this[ROUTER] = new Router({ sensitive: true }, this);
//在启动前将 router 中间件载入引用
this.beforeStart(() => {
this.use(router.middleware());
});
return router;
}

//将 router 上所有的 method 函数代理到 EggCore 上,这样我们就可以通过 app.get('/async', ...asyncMiddlewares, 'subController.subHome.async1') 的方式配置路由
utils.methods.concat([ 'all', 'resources', 'register', 'redirect' ]).forEach(method => {
EggCore.prototype[method] = function(...args) {
this.router[method](...args);
return this;
};
})

Router 类继承了 KoaRouter 类,并对其的 method 相关函数做了扩展,解析 controller 的写法,同时提供了 resources 方法,为了兼容 restAPI 的请求方式

关于 restAPI 的使用方式和实现源码我们这里就不介绍了,可以看官方文档,有具体的格式要求,下面看一下 Router 类的部分实现逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// egg-core源码 -> Router 类实现源码

class Router extends KoaRouter {
constructor(opts, app) {
super(opts);
this.app = app;
//对 method 方法进行扩展
this.patchRouterMethod();
}

patchRouterMethod() {
//为了支持 generator 函数类型,以及获取 controller 类中导出的中间件
methods.concat([ 'all' ]).forEach(method => {
this[method] = (...args) => {
// spliteAndResolveRouterParams 主要是为了拆分 router.js 中的路由规则,将其拆分成普通中间件和 controller 生成的中间件部分,请看下文源码
const splited = spliteAndResolveRouterParams({ args, app: this.app });
args = splited.prefix.concat(splited.middlewares);
return super[method](...args);
};
});
}

//返回 router 里每个路由规则的前缀和中间件部分
function spliteAndResolveRouterParams({ args, app }) {
let prefix;
let middlewares;
if (args.length >= 3 && (is.string(args[1]) || is.regExp(args[1]))) {
// app.get(name, url, [...middleware], controller) 的形式
prefix = args.slice(0, 2);
middlewares = args.slice(2);
} else {
// app.get(url, [...middleware], controller) 的形式
prefix = args.slice(0, 1);
middlewares = args.slice(1);
}
// controller 部分肯定是最后一个
const controller = middlewares.pop();
// resolveController 函数主要是为了处理 router.js 中关于 controller 的两种写法:
//写法 1 :app.get('/async', ...asyncMiddlewares, 'subController.subHome.async1')
//写法 2 :app.get('/async', ...asyncMiddlewares, subController.subHome.async1)
//最终从 app.controller 上获取到真正的 controller 中间件,resolveController 具体函数实现就不介绍了
middlewares.push(resolveController(controller, app));
return { prefix, middlewares };
}

总结

以上便是我对 egg-core 的大部分源码的实现的学习总结,其中关于源码中一些 debug 代码以及 timing 运行时间记录的代码都删掉了,关于 app 的生命周期管理的那部分代码和 loadUnits 加载逻辑关系不大,所以没有讲到。EggCore 的核心在于 EggLoader,也就是 plugin,config, extend, service, middleware, controller, router 的加载函数,而这几个内容加载必须按照顺序进行加载,存在依赖关系,比如:

  • 加载 middleware 时会用到 config 关于应用中间件的配置
  • 加载 router 时会用到关于 controller 的配置
  • 而 config,extend,service,middleware,controller 的加载都必须依赖于 plugin,通过 plugin 配置获取插件目录
  • service,middleware,controller,router 的加载又必须依赖于 extend(对 app 进行扩展),因为如果 exports 是函数的情况下,会将 app 作为参数执行函数

EggCore 是一个基础框架,其最重要的是需要遵循一定的约束和约定,可以保证一致的代码风格,而且提供了插件和框架机制,能使相同的业务逻辑实现复用,后面看有时间再写一下 Egg 框架的源码学习心得

参考文献